feat(launcher): add markdown.lua M3-subset renderer
Hand-rolled parser + renderer for the launcher's description bodies. Supports headers H1-H3, paragraphs, bullet + ordered lists, inline bold/italic/code, inline images. No tables, blockquotes, links, or code blocks. Font sizes per spec L-Q3 (H1=24, H2=20, H3=16, body=14). Images resolve via engine.module.load_texture when a module_id is provided to the renderer.
This commit is contained in:
230
markdown.lua
Normal file
230
markdown.lua
Normal file
@@ -0,0 +1,230 @@
|
|||||||
|
-- markdown.lua — M3-subset parser + renderer for launcher description bodies.
|
||||||
|
-- Supports:
|
||||||
|
-- #/##/### headers (H1=24pt, H2=20pt, H3=16pt; per L-Q3)
|
||||||
|
-- blank-line-separated paragraphs (body=14pt)
|
||||||
|
-- bold (**text**) and italic (*text*) inline spans
|
||||||
|
-- inline images: 
|
||||||
|
-- bullet lists (- item) and ordered lists (1. item)
|
||||||
|
-- inline code (`code`)
|
||||||
|
-- Does NOT support: tables, blockquotes, links, code blocks, headers >H3.
|
||||||
|
|
||||||
|
local M = {}
|
||||||
|
|
||||||
|
local H1_SIZE = 24
|
||||||
|
local H2_SIZE = 20
|
||||||
|
local H3_SIZE = 16
|
||||||
|
local BODY_SIZE = 14
|
||||||
|
local LINE_GAP = 4
|
||||||
|
local PARA_GAP = 12
|
||||||
|
local BULLET_INDENT = 18
|
||||||
|
local COLOR_TEXT = 0xE0E0E0FF
|
||||||
|
local COLOR_DIM = 0xA0A0A0FF
|
||||||
|
local COLOR_CODE = 0x80C0FFFF
|
||||||
|
|
||||||
|
-- ---- parse ----------------------------------------------------------
|
||||||
|
|
||||||
|
-- Returns a flat list of block tables. Each block:
|
||||||
|
-- {type="h1"|"h2"|"h3", text=string}
|
||||||
|
-- {type="p", spans={...}}
|
||||||
|
-- {type="img", src=string, alt=string}
|
||||||
|
-- {type="bullet", spans={...}}
|
||||||
|
-- {type="ordered", index=int, spans={...}}
|
||||||
|
|
||||||
|
local function parse_spans(line)
|
||||||
|
-- Tokenize into runs of {kind, text}: kind in plain|bold|italic|code.
|
||||||
|
local spans = {}
|
||||||
|
local i = 1
|
||||||
|
local n = #line
|
||||||
|
while i <= n do
|
||||||
|
local b, e, cap
|
||||||
|
-- Bold first (greedier delimiter).
|
||||||
|
b, e, cap = line:find("%*%*([^%*]+)%*%*", i)
|
||||||
|
if b == i then
|
||||||
|
spans[#spans + 1] = { kind = "bold", text = cap }
|
||||||
|
i = e + 1
|
||||||
|
else
|
||||||
|
b, e, cap = line:find("%*([^%*]+)%*", i)
|
||||||
|
if b == i then
|
||||||
|
spans[#spans + 1] = { kind = "italic", text = cap }
|
||||||
|
i = e + 1
|
||||||
|
else
|
||||||
|
b, e, cap = line:find("`([^`]+)`", i)
|
||||||
|
if b == i then
|
||||||
|
spans[#spans + 1] = { kind = "code", text = cap }
|
||||||
|
i = e + 1
|
||||||
|
else
|
||||||
|
-- Plain run up to the next delimiter or end-of-string.
|
||||||
|
local nxt = n + 1
|
||||||
|
for _, pat in ipairs({ "%*%*", "%*", "`" }) do
|
||||||
|
local nb = line:find(pat, i)
|
||||||
|
if nb and nb < nxt then nxt = nb end
|
||||||
|
end
|
||||||
|
spans[#spans + 1] = { kind = "plain", text = line:sub(i, nxt - 1) }
|
||||||
|
i = nxt
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return spans
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.parse(body)
|
||||||
|
local blocks = {}
|
||||||
|
if type(body) ~= "string" then return blocks end
|
||||||
|
-- Split into lines preserving blank-line boundaries.
|
||||||
|
local lines = {}
|
||||||
|
for line in (body .. "\n"):gmatch("([^\n]*)\n") do
|
||||||
|
lines[#lines + 1] = line
|
||||||
|
end
|
||||||
|
|
||||||
|
local i = 1
|
||||||
|
while i <= #lines do
|
||||||
|
local line = lines[i]
|
||||||
|
if line:match("^%s*$") then
|
||||||
|
i = i + 1
|
||||||
|
else
|
||||||
|
-- Header?
|
||||||
|
local h3 = line:match("^### (.+)$")
|
||||||
|
local h2 = line:match("^## (.+)$")
|
||||||
|
local h1 = line:match("^# (.+)$")
|
||||||
|
if h1 then
|
||||||
|
blocks[#blocks + 1] = { type = "h1", text = h1 }
|
||||||
|
i = i + 1
|
||||||
|
elseif h2 then
|
||||||
|
blocks[#blocks + 1] = { type = "h2", text = h2 }
|
||||||
|
i = i + 1
|
||||||
|
elseif h3 then
|
||||||
|
blocks[#blocks + 1] = { type = "h3", text = h3 }
|
||||||
|
i = i + 1
|
||||||
|
else
|
||||||
|
-- Inline image alone on a line?
|
||||||
|
local alt, src = line:match("^!%[(.-)%]%((.-)%)%s*$")
|
||||||
|
if alt and src then
|
||||||
|
blocks[#blocks + 1] = { type = "img", alt = alt, src = src }
|
||||||
|
i = i + 1
|
||||||
|
else
|
||||||
|
-- Bullet?
|
||||||
|
local bullet = line:match("^%- (.+)$")
|
||||||
|
if bullet then
|
||||||
|
blocks[#blocks + 1] = { type = "bullet", spans = parse_spans(bullet) }
|
||||||
|
i = i + 1
|
||||||
|
else
|
||||||
|
-- Ordered?
|
||||||
|
local idx, item = line:match("^(%d+)%. (.+)$")
|
||||||
|
if idx and item then
|
||||||
|
blocks[#blocks + 1] = {
|
||||||
|
type = "ordered",
|
||||||
|
index = tonumber(idx),
|
||||||
|
spans = parse_spans(item),
|
||||||
|
}
|
||||||
|
i = i + 1
|
||||||
|
else
|
||||||
|
-- Paragraph: concatenate consecutive non-blank, non-special lines.
|
||||||
|
local para_lines = { line }
|
||||||
|
local j = i + 1
|
||||||
|
while j <= #lines do
|
||||||
|
local nxt = lines[j]
|
||||||
|
if nxt:match("^%s*$")
|
||||||
|
or nxt:match("^#")
|
||||||
|
or nxt:match("^%- ")
|
||||||
|
or nxt:match("^%d+%. ")
|
||||||
|
or nxt:match("^!%[") then
|
||||||
|
break
|
||||||
|
end
|
||||||
|
para_lines[#para_lines + 1] = nxt
|
||||||
|
j = j + 1
|
||||||
|
end
|
||||||
|
local joined = table.concat(para_lines, " ")
|
||||||
|
blocks[#blocks + 1] = { type = "p", spans = parse_spans(joined) }
|
||||||
|
i = j
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return blocks
|
||||||
|
end
|
||||||
|
|
||||||
|
-- ---- render ---------------------------------------------------------
|
||||||
|
|
||||||
|
local function span_color(kind)
|
||||||
|
if kind == "code" then return COLOR_CODE end
|
||||||
|
return COLOR_TEXT
|
||||||
|
end
|
||||||
|
|
||||||
|
local function render_spans(spans, x, y, max_w, size)
|
||||||
|
-- Naive flow: split each span's text by spaces, wrap at max_w.
|
||||||
|
local cur_x = x
|
||||||
|
local cur_y = y
|
||||||
|
local _, line_h = engine.render.measure_text("Ay", size)
|
||||||
|
for _, sp in ipairs(spans) do
|
||||||
|
for word in sp.text:gmatch("%S+") do
|
||||||
|
local prefix = (cur_x == x) and "" or " "
|
||||||
|
local seg = prefix .. word
|
||||||
|
local w, _ = engine.render.measure_text(seg, size)
|
||||||
|
if cur_x + w > x + max_w and cur_x > x then
|
||||||
|
cur_y = cur_y + line_h + LINE_GAP
|
||||||
|
cur_x = x
|
||||||
|
seg = word
|
||||||
|
w, _ = engine.render.measure_text(seg, size)
|
||||||
|
end
|
||||||
|
engine.render.draw_text(seg, cur_x, cur_y, size, span_color(sp.kind))
|
||||||
|
cur_x = cur_x + w
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return cur_y + line_h
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.render(blocks, x, y, max_w, module_id_for_imgs)
|
||||||
|
local cur_y = y
|
||||||
|
for _, b in ipairs(blocks) do
|
||||||
|
if b.type == "h1" then
|
||||||
|
engine.render.draw_text(b.text, x, cur_y, H1_SIZE, COLOR_TEXT)
|
||||||
|
local _, h = engine.render.measure_text(b.text, H1_SIZE)
|
||||||
|
cur_y = cur_y + h + PARA_GAP
|
||||||
|
elseif b.type == "h2" then
|
||||||
|
engine.render.draw_text(b.text, x, cur_y, H2_SIZE, COLOR_TEXT)
|
||||||
|
local _, h = engine.render.measure_text(b.text, H2_SIZE)
|
||||||
|
cur_y = cur_y + h + PARA_GAP
|
||||||
|
elseif b.type == "h3" then
|
||||||
|
engine.render.draw_text(b.text, x, cur_y, H3_SIZE, COLOR_TEXT)
|
||||||
|
local _, h = engine.render.measure_text(b.text, H3_SIZE)
|
||||||
|
cur_y = cur_y + h + PARA_GAP
|
||||||
|
elseif b.type == "p" then
|
||||||
|
local ny = render_spans(b.spans, x, cur_y, max_w, BODY_SIZE)
|
||||||
|
cur_y = ny + PARA_GAP
|
||||||
|
elseif b.type == "bullet" then
|
||||||
|
engine.render.draw_text("•", x, cur_y, BODY_SIZE, COLOR_TEXT)
|
||||||
|
local ny = render_spans(b.spans, x + BULLET_INDENT, cur_y,
|
||||||
|
max_w - BULLET_INDENT, BODY_SIZE)
|
||||||
|
cur_y = ny + LINE_GAP
|
||||||
|
elseif b.type == "ordered" then
|
||||||
|
local prefix = tostring(b.index) .. "."
|
||||||
|
engine.render.draw_text(prefix, x, cur_y, BODY_SIZE, COLOR_TEXT)
|
||||||
|
local ny = render_spans(b.spans, x + BULLET_INDENT, cur_y,
|
||||||
|
max_w - BULLET_INDENT, BODY_SIZE)
|
||||||
|
cur_y = ny + LINE_GAP
|
||||||
|
elseif b.type == "img" then
|
||||||
|
-- module_id_for_imgs lets the launcher resolve "<rel>" inside
|
||||||
|
-- the module's own assets tree via engine.module.load_texture.
|
||||||
|
if module_id_for_imgs and engine.module.load_texture then
|
||||||
|
local tex = engine.module.load_texture(module_id_for_imgs, b.src)
|
||||||
|
if tex then
|
||||||
|
engine.render.draw_texture(tex, x, cur_y)
|
||||||
|
-- Texture height is not introspectable from Lua in v1;
|
||||||
|
-- assume a 16:9 max_w-wide image for layout.
|
||||||
|
cur_y = cur_y + math.floor(max_w * 9 / 16) + PARA_GAP
|
||||||
|
else
|
||||||
|
engine.render.draw_text("[image missing: " .. b.src .. "]",
|
||||||
|
x, cur_y, BODY_SIZE, COLOR_DIM)
|
||||||
|
local _, h = engine.render.measure_text("[image]", BODY_SIZE)
|
||||||
|
cur_y = cur_y + h + PARA_GAP
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return cur_y
|
||||||
|
end
|
||||||
|
|
||||||
|
return M
|
||||||
Reference in New Issue
Block a user