-- md.lua — pure-Lua Markdown → HTML renderer. -- -- Block level: ATX headings (with id anchors), fenced code blocks, -- blockquotes (nested, recursive), nested ordered/unordered lists with -- multi-paragraph items, pipe tables, horizontal rules, paragraphs, -- footnote definitions ([^id]: text). -- Inline: code spans, backslash escapes, autolinks (), -- images, links (with optional "title"), footnote refs, bold, italic, -- bold+italic, strikethrough. -- -- Link and image URLs are sanitised: only http(s), mailto, and -- relative/fragment URLs survive; anything else (javascript:, data:, -- vbscript:, …) is dropped and the text rendered plain. -- -- Not supported (by design): indented code blocks (use fences), -- setext headings, raw inline HTML (it is escaped). -- -- No mutable global state — safe to call concurrently from handlers. local M = {} -- ── HTML escaping ───────────────────────────────────────────────────── local ESC = { ["&"] = "&", ["<"] = "<", [">"] = ">", ['"'] = """ } local function esc(s) return (s:gsub('[&<>"]', ESC)) end local function slugify(s) return (s:lower() :gsub("&%a+;", "") -- drop HTML entities :gsub("<[^>]*>", "") -- drop tags (inline markup in headings) :gsub("[^%w%s-]", "") :gsub("%s+", "-") :gsub("%-+", "-") :gsub("^%-+", ""):gsub("%-+$", "")) end -- ── URL sanitiser ───────────────────────────────────────────────────── -- Returns the URL if it is safe to place in href/src, else nil. -- Input has already been HTML-escaped (quotes are "), so it cannot -- break out of the attribute; this check is about dangerous schemes. local function safe_url(u) u = u:match("^%s*(.-)%s*$") if u == "" then return nil end local l = u:lower() if l:match("^https?://") or l:match("^mailto:") then return u end -- Fragment, absolute path, query, or explicit relative path. if u:match("^[#/?]") or u:match("^%.%.?/") then return u end -- Bare relative path is fine as long as there is no scheme. if not l:match("^[%w+.-]+:") then return u end return nil end -- ── Inline markup ───────────────────────────────────────────────────── -- ctx carries footnote state (fn_defs/fn_order/fn_index); may have no -- footnotes. Code spans and finished HTML are stashed as \1\1 -- placeholders so later passes cannot touch them. local function inline(s, ctx) local holes = {} local function stash(html) holes[#holes + 1] = html return "\1" .. #holes .. "\1" end -- 1. Backslash escapes: the escaped character is stashed as a literal. s = s:gsub("\\([\\`%*_{}%[%]%(%)#%+%-%.!~|<>])", function(c) return stash(esc(c)) end) -- 2. Code spans (content is escaped, never processed further). s = s:gsub("`([^`]+)`", function(code) return stash("" .. esc(code) .. "") end) -- 3. Autolinks (before escaping eats the <>). s = s:gsub("<(https?://[^%s<>]+)>", function(url) return stash('' .. esc(url) .. "") end) -- 4. Escape HTML in the remaining text. s = esc(s) -- 5. Footnote references [^id] (before links, which share the bracket). if ctx and ctx.fn_defs then s = s:gsub("%[%^([^%]%s]+)%]", function(id) if not ctx.fn_defs[id] then return "[^" .. id .. "]" end local n = ctx.fn_index[id] if not n then ctx.fn_order[#ctx.fn_order + 1] = id n = #ctx.fn_order ctx.fn_index[id] = n end return stash('' .. '[' .. n .. ']') end) end -- 6. Images (must precede links). ![alt](url "title") s = s:gsub("!%[([^%]]*)%]%(([^%)]*)%)", function(alt, spec) local url, title = spec:match('^(.-)%s+"(.-)"%s*$') url = url or spec local safe = safe_url(url) if not safe then return alt end local t = title and (' title="' .. title .. '"') or "" return stash('' .. alt .. '') end) -- 7. Links. [text](url "title") s = s:gsub("%[([^%]]+)%]%(([^%)]*)%)", function(text, spec) local url, title = spec:match('^(.-)%s+"(.-)"%s*$') url = url or spec local safe = safe_url(url) if not safe then return text end local t = title and (' title="' .. title .. '"') or "" -- Rendered content sits inside an hx-boost region; links to binary -- endpoints must opt out or htmx would swap the file into the page. local boost = (safe:match("^/docs/") or safe == "/atom.xml") and ' hx-boost="false"' or "" -- Text may still contain emphasis markers; process below by NOT -- stashing the whole anchor — only the attribute part. return stash('") .. text .. stash("") end) -- 8. Bold + italic, bold, italic, strikethrough. s = s:gsub("%*%*%*(.-)%*%*%*", "%1") s = s:gsub("%*%*(.-)%*%*", "%1") s = s:gsub("%*([^%*]+)%*", "%1") -- Underscore emphasis only at word boundaries (leaves snake_case alone). -- Padded + double pass because adjacent matches share the boundary char. s = " " .. s .. " " for _ = 1, 2 do s = s:gsub("([%s%p])___(.-)___([%s%p])", "%1%2%3") s = s:gsub("([%s%p])__(.-)__([%s%p])", "%1%2%3") s = s:gsub("([%s%p])_([^_]+)_([%s%p])", "%1%2%3") end s = s:sub(2, -2) s = s:gsub("~~(.-)~~", "%1") -- 9. Restore stashed fragments. s = s:gsub("\1(%d+)\1", function(idx) return holes[tonumber(idx)] end) return s end -- ── Table renderer ──────────────────────────────────────────────────── local function render_table(rows, ctx) local out = { "" } local in_body = false for i, row in ipairs(rows) do -- Separator row (---|---) closes thead, opens tbody. if row:match("^[%s|%-:]+$") then if not in_body then out[#out + 1] = "" in_body = true end else local raw = {} for cell in (row .. "|"):gmatch("([^|]*)|") do raw[#raw + 1] = cell:match("^%s*(.-)%s*$") end if raw[1] == "" then table.remove(raw, 1) end if raw[#raw] == "" then raw[#raw] = nil end local tag = in_body and "td" or "th" local tr = "" for _, cell in ipairs(raw) do tr = tr .. "<" .. tag .. ">" .. inline(cell, ctx) .. "" end tr = tr .. "" if not in_body and i == 1 then out[#out + 1] = "" .. tr .. "" else out[#out + 1] = tr end end end if in_body then out[#out + 1] = "" end out[#out + 1] = "
" return table.concat(out, "\n") end -- ── List parsing ────────────────────────────────────────────────────── -- If line opens a list item, returns: prefix_len, rest, ordered, number. local function item_prefix(line) local pre, rest = line:match("^(%s*[%-%*%+]%s+)(.*)$") if pre then return #pre, rest, false, nil end local pre2, num, rest2 = line:match("^(%s*(%d+)[%.%)]%s+)(.*)$") if pre2 then return #pre2, rest2, true, tonumber(num) end return nil end local function indent_of(line) return #(line:match("^ *")) end -- True when the line begins a non-paragraph block (used to stop lazy -- paragraph continuation inside list items). local function is_block_start(line) return line:match("^%s*#") ~= nil or line:match("^%s*```") ~= nil or line:match("^%s*>") ~= nil or item_prefix(line) ~= nil or line:match("^%s*%-%-%-+%s*$") ~= nil or line:match("^%s*%*%*%*+%s*$") ~= nil end local render_blocks -- forward declaration (mutual recursion with lists) -- Parse one list starting at lines[i]. Returns html, next_index. local function parse_list(lines, i, ctx) local n = #lines local items = {} local ordered, start_num local loose = false while i <= n do local line = lines[i] if line:match("^%s*$") then -- Blank line: continuation, next item (loose), or end of list. local j = i + 1 while j <= n and lines[j]:match("^%s*$") do j = j + 1 end if j > n or #items == 0 then break end local nxt = lines[j] local ind = indent_of(nxt) local plen = item_prefix(nxt) if ind >= items[#items].content_indent then -- Indented continuation of the current item after a blank. items[#items].lines[#items[#items].lines + 1] = "" loose = true i = i + 1 elseif plen and ind <= 3 then loose = true i = j -- next iteration consumes the item start else break end else local plen, rest, ord, num = item_prefix(line) local ind = indent_of(line) -- Indented content belonging to the current item (including nested -- list items) must be checked BEFORE the new-item case. if plen and ind <= 3 and not (#items > 0 and ind >= items[#items].content_indent) then if #items == 0 then ordered = ord start_num = num elseif ord ~= ordered then break -- a different list type starts; outer loop re-parses it end items[#items + 1] = { lines = { rest }, content_indent = plen } i = i + 1 elseif #items > 0 and ind >= items[#items].content_indent then -- Indented continuation: strip the item's content indent, recurse later. items[#items].lines[#items[#items].lines + 1] = line:sub(items[#items].content_indent + 1) i = i + 1 elseif #items > 0 then -- Lazy paragraph continuation (unindented plain text). local ilines = items[#items].lines local last = ilines[#ilines] if last and not last:match("^%s*$") and not is_block_start(line) then ilines[#ilines + 1] = line:match("^%s*(.*)$") i = i + 1 else break end else break end end end local out = {} for _, item in ipairs(items) do local html = render_blocks(item.lines, ctx) if not loose then -- Tight list: unwrap the leading paragraph of each item. html = html:gsub("^

(.-)

", "%1", 1) end out[#out + 1] = "
  • " .. html .. "
  • " end local tag = ordered and "ol" or "ul" local attrs = "" if ordered and start_num and start_num ~= 1 then attrs = ' start="' .. start_num .. '"' end local html = "<" .. tag .. attrs .. ">\n" .. table.concat(out, "\n") .. "\n" return html, i end -- ── Block parser ────────────────────────────────────────────────────── render_blocks = function(lines, ctx) local out = {} local i = 1 while i <= #lines do local line = lines[i] -- Fenced code block ```[lang] if line:match("^```") then local lang = line:match("^```%s*(%w[%w+-]*)") or "" local code = {} i = i + 1 while i <= #lines and not lines[i]:match("^```%s*$") do code[#code + 1] = lines[i] i = i + 1 end i = i + 1 -- consume closing ``` local cls = lang ~= "" and (' class="language-' .. lang .. '"') or "" out[#out + 1] = "
    "
            .. esc(table.concat(code, "\n"))
            .. "
    " -- ATX headings # through ###### (with anchor ids) elseif line:match("^#+%s") then local hashes, text = line:match("^(#+)%s+(.-)%s*#*%s*$") local level = math.min(#hashes, 6) local html_text = inline(text, ctx) local id = slugify(text) if id == "" then id = "section" end if ctx.ids[id] then ctx.ids[id] = ctx.ids[id] + 1 id = id .. "-" .. ctx.ids[id] else ctx.ids[id] = 1 end out[#out + 1] = "' .. html_text .. ' #' .. "" i = i + 1 -- Blockquote > ... elseif line:match("^>") then local bq = {} while i <= #lines and lines[i]:match("^>") do bq[#bq + 1] = lines[i]:match("^>%s?(.*)") i = i + 1 end out[#out + 1] = "
    " .. render_blocks(bq, ctx) .. "
    " -- Horizontal rule --- / *** / ___ elseif line:match("^%-%-%-+%s*$") or line:match("^%*%*%*+%s*$") or line:match("^___%s*$") then out[#out + 1] = "
    " i = i + 1 -- List (ordered or unordered, possibly nested) elseif item_prefix(line) and indent_of(line) <= 3 then local html html, i = parse_list(lines, i, ctx) out[#out + 1] = html -- Pipe table (current line has | AND next line is a separator) elseif line:match("|") and i + 1 <= #lines and lines[i + 1]:match("^[%s|%-:]+$") and lines[i + 1]:match("%-") then local rows = {} while i <= #lines and lines[i]:match("|") do rows[#rows + 1] = lines[i] i = i + 1 end out[#out + 1] = render_table(rows, ctx) -- Blank line elseif line:match("^%s*$") then i = i + 1 -- Paragraph — collect until blank line or block-level element else local para = {} while i <= #lines do local l = lines[i] if l:match("^%s*$") or is_block_start(l) then break end para[#para + 1] = l i = i + 1 end if #para > 0 then out[#out + 1] = "

    " .. inline(table.concat(para, " "), ctx) .. "

    " else i = i + 1 -- defensive: never loop without progress end end end return table.concat(out, "\n") end -- ── Footnotes ───────────────────────────────────────────────────────── -- Extract footnote definitions ([^id]: text, plus indented continuation -- lines) from the line list. Returns the remaining lines. local function extract_footnotes(lines, ctx) local rest = {} local i = 1 while i <= #lines do local id, text = lines[i]:match("^%[%^([^%]%s]+)%]:%s*(.*)$") if id then local parts = { text } i = i + 1 while i <= #lines and lines[i]:match("^ %S") do parts[#parts + 1] = lines[i]:match("^ (.*)$") i = i + 1 end ctx.fn_defs[id] = table.concat(parts, " ") else rest[#rest + 1] = lines[i] i = i + 1 end end return rest end local function render_footnotes(ctx) if #ctx.fn_order == 0 then return "" end local items = {} for n, id in ipairs(ctx.fn_order) do items[#items + 1] = '
  • ' .. inline(ctx.fn_defs[id], ctx) .. '
  • ' end return '\n

      \n' .. table.concat(items, "\n") .. "\n
    " end -- ── Public API ──────────────────────────────────────────────────────── --- Render a Markdown string to an HTML string. --- Safe to call from concurrent request handlers (no global state mutated). function M.render(source) if not source or source == "" then return "" end source = source:gsub("\r\n", "\n"):gsub("\t", " ") local lines = {} for line in (source .. "\n"):gmatch("([^\n]*)\n") do lines[#lines + 1] = line end local ctx = { ids = {}, fn_defs = {}, fn_order = {}, fn_index = {} } lines = extract_footnotes(lines, ctx) return render_blocks(lines, ctx) .. render_footnotes(ctx) end return M