From 6e0c685831d31e1d757bd8a8ca7322917c2525ae Mon Sep 17 00:00:00 2001 From: Brandon Lucas Date: Wed, 18 Feb 2026 10:21:46 -0500 Subject: [PATCH] refactor: replace inline markdown with markdown package dependency Remove 606 lines of hand-rolled markdown parsing from main.lux and the unused markdown.lux split file. Replace with `import markdown` using the new markdown package (path dependency at ../markdown). This fixes the heading-in-list rendering bug where `- ### Title` was showing literal `### ` text. Now renders as `
  • Title

  • `. Also adds strikethrough support (~~text~~). Co-Authored-By: Claude Opus 4.6 --- .gitignore | 1 + lux.lock | 7 + lux.toml | 7 + main.lux | 245 +--------------------------------- markdown.lux | 364 --------------------------------------------------- 5 files changed, 18 insertions(+), 606 deletions(-) create mode 100644 lux.lock create mode 100644 lux.toml delete mode 100644 markdown.lux diff --git a/.gitignore b/.gitignore index 57510a2..7085e60 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ _site/ +.lux_packages/ diff --git a/lux.lock b/lux.lock new file mode 100644 index 0000000..9e3a69c --- /dev/null +++ b/lux.lock @@ -0,0 +1,7 @@ +# This file is auto-generated by lux pkg. Do not edit manually. + +[[package]] +name = "markdown" +version = "0.0.0" +source = "path:../markdown" + diff --git a/lux.toml b/lux.toml new file mode 100644 index 0000000..8a5a887 --- /dev/null +++ b/lux.toml @@ -0,0 +1,7 @@ +[project] +name = "blu-site" +version = "0.1.0" +description = "A Lux project" + +[dependencies] +markdown = { version = "0.0.0", path = "../markdown" } diff --git a/main.lux b/main.lux index 3fa560d..7aebe94 100644 --- a/main.lux +++ b/main.lux @@ -1,3 +1,5 @@ +import markdown + type SiteConfig = | SiteConfig(String, String, String, String, String, String, String) @@ -13,9 +15,6 @@ type Page = type FMState = | FMState(Bool, Bool, String, String, String, String, String) -type BState = - | BState(String, String, Bool, String, String, Bool, String, Bool, String, Bool) - type TagEntry = | TagEntry(String, String, String, String, String) @@ -197,10 +196,6 @@ fn teSection(e: TagEntry): String = TagEntry(_, _, _, _, s) => s, } -fn escapeHtml(s: String): String = String.replace(String.replace(String.replace(s, "&", "&"), "<", "<"), ">", ">") - -fn escapeHtmlCode(s: String): String = String.replace(String.replace(String.replace(s, "&", "&"), "<", "<"), ">", ">") - fn slugFromFilename(filename: String): String = if String.endsWith(filename, ".md") then String.substring(filename, 0, String.length(filename) - 3) else filename fn formatDate(isoDate: String): String = { @@ -239,241 +234,7 @@ fn insertByDate(sorted: List, item: Page): List = { } } -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)) - -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" - -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 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 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 linkTag = acc + "" + processInline(String.substring(text, i + 1, end)) + "" - 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), "") - -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, -} - -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 + "
    -" + convertMd(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 + " -" -} - -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 -} -} - -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) -} - -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) - 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 + escapeHtmlCode(line) + " -", false, "", false, "", false) 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) -} 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) -} else if String.trim(line) == ">" then { - let h2 = flushPara(html, para) - let h3 = flushList(h2, listItems, ordered) - BState(h3, "", false, "", "", true, bqLines + " -", false, "", false) -} else if inBq then { - let h2 = flushBq(html, bqLines) - processBlockLine(h2, para, inList, listItems, ordered, line) -} 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 + "
  • " + processInline(item) + "
  • -", false) -} 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 + "
  • " + processInline(item) + "
  • -", true) -} else processBlockLine(html, para, inList, listItems, ordered, line) -} - -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)) -} - -fn convertMd(markdown: String): String = parseBlocks(markdown) +fn convertMd(text: String): String = markdown.toHtml(text) fn htmlHead(title: String, description: String): String = "" + "" + title + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" + "" diff --git a/markdown.lux b/markdown.lux deleted file mode 100644 index d753e2e..0000000 --- a/markdown.lux +++ /dev/null @@ -1,364 +0,0 @@ -// 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)