(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('')
end)
end
-- 6. Images (must precede links). 
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('
')
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) .. "" .. tag .. ">"
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" .. tag .. ">"
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"
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