455 lines
16 KiB
Lua
455 lines
16 KiB
Lua
-- 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 (<https://…>),
|
|
-- 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<n>\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("<code>" .. esc(code) .. "</code>")
|
|
end)
|
|
|
|
-- 3. Autolinks <https://example.com> (before escaping eats the <>).
|
|
s = s:gsub("<(https?://[^%s<>]+)>", function(url)
|
|
return stash('<a href="' .. esc(url) .. '">' .. esc(url) .. "</a>")
|
|
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('<sup class="footnote-ref" id="fnref-' .. n .. '">'
|
|
.. '<a href="#fn-' .. n .. '">[' .. n .. ']</a></sup>')
|
|
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('<img src="' .. safe .. '" alt="' .. alt .. '"' .. t .. ' loading="lazy">')
|
|
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('<a href="' .. safe .. '"' .. t .. boost .. ">") .. text .. stash("</a>")
|
|
end)
|
|
|
|
-- 8. Bold + italic, bold, italic, strikethrough.
|
|
s = s:gsub("%*%*%*(.-)%*%*%*", "<strong><em>%1</em></strong>")
|
|
s = s:gsub("%*%*(.-)%*%*", "<strong>%1</strong>")
|
|
s = s:gsub("%*([^%*]+)%*", "<em>%1</em>")
|
|
-- 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<strong><em>%2</em></strong>%3")
|
|
s = s:gsub("([%s%p])__(.-)__([%s%p])", "%1<strong>%2</strong>%3")
|
|
s = s:gsub("([%s%p])_([^_]+)_([%s%p])", "%1<em>%2</em>%3")
|
|
end
|
|
s = s:sub(2, -2)
|
|
s = s:gsub("~~(.-)~~", "<del>%1</del>")
|
|
|
|
-- 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 = { "<table>" }
|
|
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] = "<tbody>"
|
|
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 = "<tr>"
|
|
for _, cell in ipairs(raw) do
|
|
tr = tr .. "<" .. tag .. ">" .. inline(cell, ctx) .. "</" .. tag .. ">"
|
|
end
|
|
tr = tr .. "</tr>"
|
|
|
|
if not in_body and i == 1 then
|
|
out[#out + 1] = "<thead>" .. tr .. "</thead>"
|
|
else
|
|
out[#out + 1] = tr
|
|
end
|
|
end
|
|
end
|
|
if in_body then out[#out + 1] = "</tbody>" end
|
|
out[#out + 1] = "</table>"
|
|
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("^<p>(.-)</p>", "%1", 1)
|
|
end
|
|
out[#out + 1] = "<li>" .. html .. "</li>"
|
|
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] = "<pre><code" .. cls .. ">"
|
|
.. esc(table.concat(code, "\n"))
|
|
.. "</code></pre>"
|
|
|
|
-- 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] = "<h" .. level .. ' id="' .. id .. '">'
|
|
.. html_text
|
|
.. ' <a class="heading-anchor" href="#' .. id
|
|
.. '" aria-label="Link to this section">#</a>'
|
|
.. "</h" .. level .. ">"
|
|
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] = "<blockquote>" .. render_blocks(bq, ctx) .. "</blockquote>"
|
|
|
|
-- Horizontal rule --- / *** / ___
|
|
elseif line:match("^%-%-%-+%s*$")
|
|
or line:match("^%*%*%*+%s*$")
|
|
or line:match("^___%s*$") then
|
|
out[#out + 1] = "<hr>"
|
|
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] = "<p>" .. inline(table.concat(para, " "), ctx) .. "</p>"
|
|
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] = '<li id="fn-' .. n .. '">'
|
|
.. inline(ctx.fn_defs[id], ctx)
|
|
.. ' <a href="#fnref-' .. n .. '" class="footnote-back"'
|
|
.. ' aria-label="Back to reference">↩</a></li>'
|
|
end
|
|
return '\n<section class="footnotes"><hr><ol>\n'
|
|
.. table.concat(items, "\n")
|
|
.. "\n</ol></section>"
|
|
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
|