-- 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: ![alt](rel/path.png) -- 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 "" 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