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 `<li><h3>Title</h3></li>`.
Also adds strikethrough support (~~text~~).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 10:21:46 -05:00
parent ec124768dc
commit 6e0c685831
5 changed files with 18 additions and 606 deletions

1
.gitignore vendored
View File

@@ -1 +1,2 @@
_site/
.lux_packages/

7
lux.lock Normal file
View File

@@ -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"

7
lux.toml Normal file
View File

@@ -0,0 +1,7 @@
[project]
name = "blu-site"
version = "0.1.0"
description = "A Lux project"
[dependencies]
markdown = { version = "0.0.0", path = "../markdown" }

245
main.lux
View File

@@ -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, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
fn escapeHtmlCode(s: String): String = String.replace(String.replace(String.replace(s, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
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<Page>, item: Page): List<Page> = {
}
}
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))
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 + "<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 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 imgTag = acc + "<img src=\"" + String.substring(text, end + 2, urlEnd) + "\" alt=\"" + String.substring(text, i + 2, end) + "\">"
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 + "<a href=\"" + String.substring(text, end + 2, urlEnd) + "\">" + processInline(String.substring(text, i + 1, end)) + "</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), "")
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 + "<p>" + processInline(String.trim(para)) + "</p>
"
fn flushBq(html: String, bqLines: String): String =
if bqLines == "" then html else html + "<blockquote>
" + convertMd(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 + ">
"
}
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 + "<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)
}
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 "<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 + 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 + "<li>" + processInline(item) + "</li>
", 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 + "<li>" + processInline(item) + "</li>
", 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 = "<!doctype html><html lang=\"en\"><head>" + "<title>" + title + "</title>" + "<meta charset=\"utf-8\">" + "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">" + "<meta name=\"description\" content=\"" + description + "\">" + "<meta property=\"og:title\" content=\"" + title + "\">" + "<meta property=\"og:description\" content=\"" + description + "\">" + "<meta property=\"og:type\" content=\"website\">" + "<meta property=\"og:url\" content=\"https://blu.cx\">" + "<meta property=\"og:image\" content=\"https://blu.cx/images/social-card.png\">" + "<meta property=\"og:site_name\" content=\"Brandon Lucas\">" + "<meta name=\"twitter:card\" content=\"summary_large_image\">" + "<meta name=\"twitter:title\" content=\"" + title + "\">" + "<meta name=\"twitter:description\" content=\"" + description + "\">" + "<link rel=\"canonical\" href=\"https://blu.cx\">" + "<link rel=\"preload\" href=\"/fonts/EBGaramond-Regular.woff2\" as=\"font\" type=\"font/woff2\" crossorigin=\"\">" + "<link rel=\"preload\" href=\"/fonts/UnifrakturMaguntia-Regular.woff2\" as=\"font\" type=\"font/woff2\" crossorigin=\"\">" + "<link href=\"/styles.css\" rel=\"stylesheet\" type=\"text/css\">" + "<link href=\"/highlight/tokyo-night-dark.min.css\" rel=\"stylesheet\" type=\"text/css\">" + "<script src=\"/highlight/highlight.min.js\" defer=\"\"></script>" + "<script>document.addEventListener('DOMContentLoaded', function() \{ hljs.highlightAll(); \});</script>" + "</head>"

View File

@@ -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 + "<strong>" + processInline(inner) + "</strong>")
},
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 + "<em>" + processInline(inner) + "</em>")
},
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 + "<em>" + processInline(inner) + "</em>")
},
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 + "<code>" + inner + "</code>")
},
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 + "<a href=\"" + url + "\">" + processInline(linkText) + "</a>")
},
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 + "<img src=\"" + url + "\" alt=\"" + alt + "\">")
},
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 + "&amp;")
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 + "&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 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<Int> = {
let delimLen = String.length(delim);
findClosingFrom(text, start, len, delim, delimLen)
}
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)
}
// === 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 + "<p>" + processInline(String.trim(para)) + "</p>\n"
// Flush accumulated blockquote
fn flushBq(html: String, bqLines: String): String =
if bqLines == "" then html
else html + "<blockquote>\n" + convert(bqLines) + "</blockquote>\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 + "</" + tag + ">\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
"<pre><code>" + codeLines + "</code></pre>\n"
else
"<pre><code class=\"language-" + codeLang + "\">" + codeLines + "</code></pre>\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 + "<li>" + processInline(item) + "</li>\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 + "<li>" + processInline(item) + "</li>\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 + "<h1>" + processInline(content) + "</h1>\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 + "<h2>" + processInline(content) + "</h2>\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 + "<h3>" + processInline(content) + "</h3>\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 + "<h4>" + processInline(content) + "</h4>\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 + "<hr>\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 + "<p>" + processInline(trimmed) + "</p>\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, "&", "&amp;"), "<", "&lt;"), ">", "&gt;")
// === Main convert function ===
// Convert full markdown text to HTML
pub fn convert(markdown: String): String =
parseBlocks(markdown)