// Pure Lux markdown-to-HTML converter // Handles block-level and inline-level markdown elements // === Inline processing === // Process inline markdown: **bold**, *italic*/_italic_, `code`, [links](url) // Uses index-based scanning pub fn processInline(text: String): String = { let len = String.length(text); processInlineFrom(text, 0, len, "") } 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); // ** bold ** if ch == "*" then if i + 1 < len then if String.substring(text, i + 1, i + 2) == "*" then // Look for closing ** match findClosing(text, i + 2, len, "**") { Some(end) => { let inner = String.substring(text, i + 2, end); processInlineFrom(text, end + 2, len, acc + "" + processInline(inner) + "") }, None => processInlineFrom(text, i + 1, len, acc + ch) } else // Single * italic match findClosing(text, i + 1, len, "*") { Some(end) => { let inner = String.substring(text, i + 1, end); processInlineFrom(text, end + 1, len, acc + "" + processInline(inner) + "") }, None => processInlineFrom(text, i + 1, len, acc + ch) } else acc + ch // _italic_ else if ch == "_" then if i + 1 < len then match findClosing(text, i + 1, len, "_") { Some(end) => { let inner = String.substring(text, i + 1, end); processInlineFrom(text, end + 1, len, acc + "" + processInline(inner) + "") }, None => processInlineFrom(text, i + 1, len, acc + ch) } else acc + ch // `code` else if ch == "`" then match findClosing(text, i + 1, len, "`") { Some(end) => { let inner = String.substring(text, i + 1, end); processInlineFrom(text, end + 1, len, acc + "" + inner + "") }, None => processInlineFrom(text, i + 1, len, acc + ch) } // [text](url) else if ch == "[" then match findClosing(text, i + 1, len, "](") { Some(end) => { let linkText = String.substring(text, i + 1, end); match findClosing(text, end + 2, len, ")") { Some(urlEnd) => { let url = String.substring(text, end + 2, urlEnd); processInlineFrom(text, urlEnd + 1, len, acc + "" + processInline(linkText) + "") }, None => processInlineFrom(text, i + 1, len, acc + ch) } }, None => processInlineFrom(text, i + 1, len, acc + ch) } // ![alt](url) — images 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) => { let alt = String.substring(text, i + 2, end); match findClosing(text, end + 2, len, ")") { Some(urlEnd) => { let url = String.substring(text, end + 2, urlEnd); processInlineFrom(text, urlEnd + 1, len, acc + "\""") }, 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 // HTML entities: & < > else if ch == "&" then processInlineFrom(text, i + 1, len, acc + "&") else if ch == "<" then // Check if this looks like an HTML tag — pass through if i + 1 < len then if isHtmlTagStart(text, i, len) then { // Find closing > match findClosing(text, i + 1, len, ">") { Some(end) => { let tag = String.substring(text, i, end + 1); processInlineFrom(text, end + 1, len, acc + tag) }, 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 isHtmlTagStart(text: String, i: Int, len: Int): Bool = { // Check if < is followed by a letter or / (closing tag) if i + 1 >= len then false else { let next = String.substring(text, i + 1, i + 2); next == "/" || next == "a" || next == "b" || next == "c" || next == "d" || next == "e" || next == "f" || next == "g" || next == "h" || next == "i" || next == "j" || next == "k" || next == "l" || next == "m" || next == "n" || next == "o" || next == "p" || next == "q" || next == "r" || next == "s" || next == "t" || next == "u" || next == "v" || next == "w" || next == "x" || next == "y" || next == "z" || next == "A" || next == "B" || next == "C" || next == "D" || next == "E" || next == "F" || next == "G" || next == "H" || next == "I" || next == "J" || next == "K" || next == "L" || next == "M" || next == "N" || next == "O" || next == "P" || next == "Q" || next == "R" || next == "S" || next == "T" || next == "U" || next == "V" || next == "W" || next == "X" || next == "Y" || next == "Z" || next == "!" } } // Find a closing delimiter starting from position i fn findClosing(text: String, start: Int, len: Int, delim: String): Option = { let delimLen = String.length(delim); findClosingFrom(text, start, len, delim, delimLen) } 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) } // === Block-level processing === // State for block parser accumulator // (blocks_html, current_paragraph_lines, in_code_block, code_lang, code_lines, in_blockquote, bq_lines, in_list, list_items, is_ordered) pub type BlockState = | BlockState(String, String, Bool, String, String, Bool, String, Bool, String, Bool) fn bsHtml(s: BlockState): String = match s { BlockState(h, _, _, _, _, _, _, _, _, _) => h } fn bsPara(s: BlockState): String = match s { BlockState(_, p, _, _, _, _, _, _, _, _) => p } fn bsInCode(s: BlockState): Bool = match s { BlockState(_, _, c, _, _, _, _, _, _, _) => c } fn bsCodeLang(s: BlockState): String = match s { BlockState(_, _, _, l, _, _, _, _, _, _) => l } fn bsCodeLines(s: BlockState): String = match s { BlockState(_, _, _, _, cl, _, _, _, _, _) => cl } fn bsInBq(s: BlockState): Bool = match s { BlockState(_, _, _, _, _, bq, _, _, _, _) => bq } fn bsBqLines(s: BlockState): String = match s { BlockState(_, _, _, _, _, _, bl, _, _, _) => bl } fn bsInList(s: BlockState): Bool = match s { BlockState(_, _, _, _, _, _, _, il, _, _) => il } fn bsListItems(s: BlockState): String = match s { BlockState(_, _, _, _, _, _, _, _, li, _) => li } fn bsOrdered(s: BlockState): Bool = match s { BlockState(_, _, _, _, _, _, _, _, _, o) => o } // Flush accumulated paragraph fn flushPara(html: String, para: String): String = if para == "" then html else html + "

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

\n" // Flush accumulated blockquote fn flushBq(html: String, bqLines: String): String = if bqLines == "" then html else html + "
\n" + convert(bqLines) + "
\n" // Flush accumulated list fn flushList(html: String, listItems: String, ordered: Bool): String = if listItems == "" then html else { let tag = if ordered then "ol" else "ul"; html + "<" + tag + ">\n" + listItems + "\n" } // Parse markdown blocks from text pub fn parseBlocks(text: String): String = { let lines = String.lines(text); let init = BlockState("", "", false, "", "", false, "", false, "", false); let final = List.fold(lines, init, fn(state: BlockState, line: String): BlockState => { 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 code block if inCode then if String.startsWith(line, "```") then { // End code block let codeHtml = if codeLang == "" then "
" + codeLines + "
\n" else "
" + codeLines + "
\n"; BlockState(html + codeHtml, "", false, "", "", false, "", false, "", false) } else BlockState(html, para, true, codeLang, codeLines + escapeHtmlCode(line) + "\n", false, "", false, "", false) // Start 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))); BlockState(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)); BlockState(h3, "", false, "", "", true, bqLines + bqContent + "\n", false, "", false) } else if String.trim(line) == ">" then { // Empty blockquote continuation let h2 = flushPara(html, para); let h3 = flushList(h2, listItems, ordered); BlockState(h3, "", false, "", "", true, bqLines + "\n", false, "", false) } // End of blockquote (non-bq line after bq lines) else if inBq then { let h2 = flushBq(html, bqLines); // Re-process this line processLine(BlockState(h2, para, false, "", "", false, "", 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)); BlockState(h2, "", false, "", "", false, "", true, listItems + "
  • " + processInline(item) + "
  • \n", false) } // Ordered list item (starts with digit + ". ") 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)); BlockState(h2, "", false, "", "", false, "", true, listItems + "
  • " + processInline(item) + "
  • \n", true) } else processLine(state, line) }); // Flush remaining state let h = flushPara(bsHtml(final), bsPara(final)); let h2 = flushBq(h, bsBqLines(final)); let h3 = flushList(h2, bsListItems(final), bsOrdered(final)); h3 } fn processLine(state: BlockState, line: String): BlockState = { let html = bsHtml(state); let para = bsPara(state); let inList = bsInList(state); let listItems = bsListItems(state); let ordered = bsOrdered(state); let trimmed = String.trim(line); // Blank line — flush paragraph and list if trimmed == "" then { let h2 = flushPara(html, para); let h3 = flushList(h2, listItems, ordered); BlockState(h3, "", false, "", "", false, "", false, "", false) } // Heading else if String.startsWith(trimmed, "# ") then { let h2 = flushPara(html, para); let h3 = flushList(h2, listItems, ordered); let content = String.substring(trimmed, 2, String.length(trimmed)); BlockState(h3 + "

    " + processInline(content) + "

    \n", "", false, "", "", false, "", false, "", false) } else if String.startsWith(trimmed, "## ") then { let h2 = flushPara(html, para); let h3 = flushList(h2, listItems, ordered); let content = String.substring(trimmed, 3, String.length(trimmed)); BlockState(h3 + "

    " + processInline(content) + "

    \n", "", false, "", "", false, "", false, "", false) } else if String.startsWith(trimmed, "### ") then { let h2 = flushPara(html, para); let h3 = flushList(h2, listItems, ordered); let content = String.substring(trimmed, 4, String.length(trimmed)); BlockState(h3 + "

    " + processInline(content) + "

    \n", "", false, "", "", false, "", false, "", false) } else if String.startsWith(trimmed, "#### ") then { let h2 = flushPara(html, para); let h3 = flushList(h2, listItems, ordered); let content = String.substring(trimmed, 5, String.length(trimmed)); BlockState(h3 + "

    " + processInline(content) + "

    \n", "", false, "", "", false, "", false, "", false) } // Horizontal rule else if trimmed == "---" || trimmed == "***" || trimmed == "___" then { let h2 = flushPara(html, para); let h3 = flushList(h2, listItems, ordered); BlockState(h3 + "
    \n", "", false, "", "", false, "", false, "", false) } // Raw HTML line (starts with <) else if String.startsWith(trimmed, "<") then { let h2 = flushPara(html, para); let h3 = flushList(h2, listItems, ordered); BlockState(h3 + trimmed + "\n", "", false, "", "", false, "", false, "", false) } // Image on its own line else if String.startsWith(trimmed, "![") then { let h2 = flushPara(html, para); let h3 = flushList(h2, listItems, ordered); BlockState(h3 + "

    " + processInline(trimmed) + "

    \n", "", false, "", "", false, "", false, "", false) } // Continuation of list (indented or sub-item) else if inList then if String.startsWith(line, " ") then { // Indented content under a list item — append to last item BlockState(html, para, false, "", "", false, "", true, listItems, ordered) } else { // Not a list item — flush list, treat as paragraph let h2 = flushList(html, listItems, ordered); BlockState(h2, para + trimmed + " ", false, "", "", false, "", false, "", false) } // Regular text — accumulate into paragraph else BlockState(html, para + trimmed + " ", false, "", "", false, "", false, "", false) } 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 } } // Escape HTML special chars in code blocks (no inline processing) fn escapeHtmlCode(s: String): String = String.replace(String.replace(String.replace(s, "&", "&"), "<", "<"), ">", ">") // === Main convert function === // Convert full markdown text to HTML pub fn convert(markdown: String): String = parseBlocks(markdown)