Initial commit, blog system works
This commit is contained in:
454
.lua/md.lua
Normal file
454
.lua/md.lua
Normal file
@@ -0,0 +1,454 @@
|
||||
-- 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
|
||||
Reference in New Issue
Block a user